fix(openai): generically handle models that reject unsupported request params - #1852
fix(openai): generically handle models that reject unsupported request params#1852arimu1 wants to merge 1 commit into
Conversation
Address @igordayen review on embabel#1852: - Rename UnsupportedRequestParameterHandler → UnsupportedRequestParameterRetry (avoid "Handler" event/callback connotation) - Expand KDoc: not a structured provider API; OpenAI/Azure message pattern; fail closed; strip when() maintenance notes; normalize is not Spring binder - Clarify Prompt.options is required for strip retry (else rethrow) - Rename SpecialHandlingConfiguration → SupportFeaturesConfiguration (YAML special_handling property name unchanged) Tests: UnsupportedRequestParameterRetryTest, InstrumentedChatModelTest, CapabilityAwareOpenAiOptionsConverterTest, OpenAiModelLoaderTest green (JDK 21) Signed-off-by: arimu1 <19286898+arimu1@users.noreply.github.com>
|
@igordayen Thanks for the thorough review — addressed on tip Naming
Reliability of message parse (is there an API?)Not guaranteed across providers. Spring AI / provider clients typically surface the rejection as exception message text only. OpenAI’s JSON body can include Maintaining the
|
@arimu1 - yes, that is a concern. What about non-OpenAI providers? |
|
From Claude: This is confirmed by other Spring AI issues where the exception message is literally "400 - " followed by the complete error JSON, including That means extraction is just: strip the private Optional<OpenAiErrorDetail> extractErrorDetail(NonTransientAiException e) {
String message = e.getMessage();
int jsonStart = message.indexOf('{');
if (jsonStart < 0) {
return Optional.empty(); // not a structured OpenAI error body
}
try {
JsonNode root = objectMapper.readTree(message.substring(jsonStart));
JsonNode error = root.path("error");
if (error.isMissingNode()) {
return Optional.empty();
}
return Optional.of(new OpenAiErrorDetail(
error.path("code").asText(null),
error.path("param").asText(null),
error.path("type").asText(null)
));
} catch (JsonProcessingException parseFailure) {
log.debug("Error body wasn't valid JSON, treating as unstructured", parseFailure);
return Optional.empty();
}
}Then your retry gate becomes: Optional<OpenAiErrorDetail> detail = extractErrorDetail(e);
if (detail.isPresent() && "unsupported_value".equals(detail.get().code()) && detail.get().param() != null) {
// strip detail.get().param() and retry once
} else {
throw e; // fail closed on anything unstructured
}Two caveats worth flagging:
This is strictly better than the earlier regex approach: you're parsing the actual structured error OpenAI sent, just recovered from where Spring AI happens to have stashed it, rather than pattern-matching English prose. |
|
from Claude - even more: Here's a config-driven version:
# Models that reject specific ChatOptions parameters, or accept them
# only at a fixed value. Source: OpenAI docs / observed 400s.
# Update this file when a new restriction is discovered — no redeploy
# should require touching Java code.
restrictedModels:
gpt-5:
unsupportedParams: [temperature, top_p]
lastVerified: "2026-07-01"
notes: "Only default temperature (1) accepted"
gpt-5-mini:
unsupportedParams: [temperature, top_p]
lastVerified: "2026-07-01"
o3-mini:
unsupportedParams: [temperature, top_p, presence_penalty, frequency_penalty]
lastVerified: "2026-06-15"
o1:
unsupportedParams: [temperature, top_p, presence_penalty, frequency_penalty]
lastVerified: "2026-05-20"
notes: "logprobs also unsupported but not a ChatOptions field today"Loader + converter public record RestrictedModelSpec(String model, Set<String> unsupportedParams) {}
@Component
public class RestrictedModelRegistry {
private final Map<String, Set<String>> restrictions;
public RestrictedModelRegistry(
@Value("classpath:restricted-model-options.yaml") Resource resource,
ObjectMapper yamlMapper) throws IOException {
JsonNode root = yamlMapper.readTree(resource.getInputStream());
JsonNode models = root.path("restrictedModels");
Map<String, Set<String>> parsed = new HashMap<>();
models.fields().forEachRemaining(entry -> {
String modelName = entry.getKey();
Set<String> params = new HashSet<>();
entry.getValue().path("unsupportedParams")
.forEach(p -> params.add(p.asText()));
parsed.put(modelName, Set.copyOf(params));
});
this.restrictions = Map.copyOf(parsed);
}
public Set<String> unsupportedParamsFor(String model) {
return restrictions.getOrDefault(model, Set.of());
}
/** For the Layer-2 "learn once, remember" cache to feed back into. */
public void recordDiscovered(String model, String param) {
// if you want runtime learning to persist across restarts,
// write this to a side file/DB rather than mutating `restrictions`
// (which is intentionally immutable — see below)
}
}Note The converter, now table-driven instead of hardcoded @Component
public class RestrictedModelOptionsConverter implements OptionsConverter {
private final RestrictedModelRegistry registry;
RestrictedModelOptionsConverter(RestrictedModelRegistry registry) {
this.registry = registry;
}
@Override
public ChatOptions convert(String model, ChatOptions options) {
Set<String> restricted = registry.unsupportedParamsFor(model);
if (restricted.isEmpty()) return options;
var mutated = options.mutate();
if (restricted.contains("temperature") && options.getTemperature() != null) {
mutated.temperature(null);
log.warn("Dropped temperature for restricted model {}", model);
}
if (restricted.contains("top_p") && options.getTopP() != null) {
mutated.topP(null);
log.warn("Dropped top_p for restricted model {}", model);
}
// extend per param — a small reflective/functional map from
// param-name -> BiConsumer<Mutator, Options> avoids this if/else
// ladder growing unboundedly; worth doing once you're past ~5 params
return mutated.build();
}
}One test to keep this honest @Test
void yamlLoadsAndMatchesKnownRestrictedModel() {
var registry = new RestrictedModelRegistry(
new ClassPathResource("restricted-model-options.yaml"), yamlMapper);
assertThat(registry.unsupportedParamsFor("gpt-5"))
.containsExactlyInAnyOrder("temperature", "top_p");
assertThat(registry.unsupportedParamsFor("gpt-4o"))
.isEmpty(); // unrestricted model, sanity check
}Two things worth deciding before this goes in:
|
Address @igordayen review on embabel#1852: - Rename UnsupportedRequestParameterHandler → UnsupportedRequestParameterRetry (avoid "Handler" event/callback connotation) - Expand KDoc: not a structured provider API; OpenAI/Azure message pattern; fail closed; strip when() maintenance notes; normalize is not Spring binder - Clarify Prompt.options is required for strip retry (else rethrow) - Rename SpecialHandlingConfiguration → SupportFeaturesConfiguration (YAML special_handling property name unchanged) Tests: UnsupportedRequestParameterRetryTest, InstrumentedChatModelTest, CapabilityAwareOpenAiOptionsConverterTest, OpenAiModelLoaderTest green (JDK 21) Signed-off-by: arimu1 <19286898+arimu1@users.noreply.github.com>
…of truth Rebased on main (OptionsConverter 2-arg + model stamp). Prefer OpenAI JSON error.param from Spring AI exception messages over English wording for the one-shot retry safety net. Declarative omit via openai-models.yml special_handling → ModelCapabilities remains the primary path (LLM model database). Fail closed on non-retryable error codes. Addresses igordayen design feedback on embabel#1852 / embabel#1724. Signed-off-by: arimu1 <19286898+arimu1@users.noreply.github.com>
69c9bd1 to
019aad8
Compare
|
@igordayen Thanks — agreed the wording-only retry was the weak layer. Pushed tip Design stance (aligned with your / Claude notes)1. LLM model database is the source of truth (primary path)
That is the same role as the suggested restricted-model registry, but it stays inside the existing YAML model DB so maintainers update one place. GPT-4.1 family already has 2. Retry is only a safety net 3. Extraction no longer depends on English prose first We now:
So the retry gate matches the structured field OpenAI actually sends, not prose. Non-OpenAI providers rarely share this shape; they fail closed (no silent wrong retry). What we did not do (on purpose)
Happy to iterate if you and @alexheifetz want restrictions expressed as an explicit Ready for re-review when convenient. |
@arimu1 - thank you for addressing the inquiries. |
|
Reasoning on Spring AI behavior for temperature support (generated by GPT): The short answer is: it's not a Spring AI limitation. It is an OpenAI model capability limitation, and Spring AI 2.0 intentionally exposes only the parameters that the underlying model accepts. The confusion comes from the fact that there are now two families of GPT models. Model family | temperature | Why -- | -- | -- GPT-4o, GPT-4.1, GPT-5 Chat | ✅ Supported | Traditional autoregressive chat models GPT-5, GPT-5-mini, GPT-5-nano (reasoning) | ❌ Not supported | Reasoning models have fixed sampling behaviorSpring AI documents this explicitly:
Why did OpenAI remove temperature?This is an architectural decision. Older GPT models generate tokens directly, so temperature modifies the probability distribution: Higher temperature:
Lower temperature:
Reasoning models work differently. Instead of directly sampling the answer, they internally perform reasoning/search/planning before producing the final response. OpenAI therefore controls the sampling strategy internally and exposes reasoning effort rather than sampling controls. Instead of you tune which affects how much reasoning budget the model spends rather than how random token sampling is. (GitHub) Why doesn't Spring AI silently ignore |
| Model family | temperature |
Why |
|---|---|---|
| GPT-4o, GPT-4.1, GPT-5 Chat | ✅ Supported | Traditional autoregressive chat models |
| GPT-5, GPT-5-mini, GPT-5-nano (reasoning) | ❌ Not supported | Reasoning models have fixed sampling behavior |
Spring AI documents this explicitly:
When using GPT-5 models such as
gpt-5,gpt-5-mini, andgpt-5-nano, thetemperatureparameter is not supported. Specifying it results in an API error. Conversely,gpt-5-chatsupportstemperature. ([Home]1)
Why did OpenAI remove temperature?
This is an architectural decision.
Older GPT models generate tokens directly, so temperature modifies the probability distribution:
softmax(logits / temperature)
Higher temperature:
- more randomness
- more creative
- less deterministic
Lower temperature:
- more deterministic
- better for code
- less diverse
Reasoning models work differently.
Instead of directly sampling the answer, they internally perform reasoning/search/planning before producing the final response. OpenAI therefore controls the sampling strategy internally and exposes reasoning effort rather than sampling controls.
Instead of
temperature = 0.2
you tune
reasoning_effort = low | medium | high
which affects how much reasoning budget the model spends rather than how random token sampling is. ([GitHub]2)
Why doesn't Spring AI silently ignore temperature?
Because that would hide configuration mistakes.
Suppose you configure
OpenAiChatOptions.builder()
.model("gpt-5")
.temperature(0.0)
.build();If Spring AI simply dropped the parameter, your application would behave differently from what you requested.
Instead it forwards the request to OpenAI, which returns
400
Unsupported parameter: 'temperature'
This makes the incompatibility obvious.
Why does Spring AI still have temperature() in OpenAiChatOptions?
Because one options class supports many models:
- GPT-4o
- GPT-4.1
- GPT-5 Chat
- Azure OpenAI
- OpenAI-compatible providers
- local OpenAI-compatible servers
Most of those do support temperature.
Spring AI cannot remove the field because it is valid for many models.
Is this ideal?
From an API-design perspective, probably not.
A stronger type-safe design would distinguish between model capabilities, for example:
interface SamplingCapability {
Temperature temperature();
TopP topP();
}
interface ReasoningCapability {
ReasoningEffort reasoningEffort();
}or expose a per-model capability API such as:
chatModel.capabilities().supportsTemperature()
chatModel.capabilities().supportsReasoningEffort()Then Spring AI (or your own abstraction) could validate options before sending the request.
In fact, this aligns well with the capability-based design you've been developing for caching (LLMCachingCapability) and other vendor-specific features. The same pattern could naturally extend to sampling, reasoning, structured output, streaming, and similar features.
In the current agent-api, temperature is a generic property across all models / provides; see:
OptionsConverter.kt and LlmOptions.kt
Should it be this way, or to be removed from common interfaces and have a "minimum" interface to deal with this.
Is run-time detection the only option?
Obviously, compile-time control gets more complex because within OpenAI it behaves differently per model,
Could you please try to analyze the feasibility of compile-time control?
@alexheifetz - FYI
Address @igordayen review on embabel#1852: - Rename UnsupportedRequestParameterHandler → UnsupportedRequestParameterRetry (avoid "Handler" event/callback connotation) - Expand KDoc: not a structured provider API; OpenAI/Azure message pattern; fail closed; strip when() maintenance notes; normalize is not Spring binder - Clarify Prompt.options is required for strip retry (else rethrow) - Rename SpecialHandlingConfiguration → SupportFeaturesConfiguration (YAML special_handling property name unchanged) Tests: UnsupportedRequestParameterRetryTest, InstrumentedChatModelTest, CapabilityAwareOpenAiOptionsConverterTest, OpenAiModelLoaderTest green (JDK 21) Signed-off-by: arimu1 <19286898+arimu1@users.noreply.github.com>
…of truth Rebased on main (OptionsConverter 2-arg + model stamp). Prefer OpenAI JSON error.param from Spring AI exception messages over English wording for the one-shot retry safety net. Declarative omit via openai-models.yml special_handling → ModelCapabilities remains the primary path (LLM model database). Fail closed on non-retryable error codes. Addresses igordayen design feedback on embabel#1852 / embabel#1724. Signed-off-by: arimu1 <19286898+arimu1@users.noreply.github.com>
Prefer fail-closed extraction: only structured OpenAI JSON error.param (with known codes) drives the one-shot strip/retry. Prose-only "Unsupported value: '…'" messages no longer match. Model YAML special_handling remains the primary defence (embabel#1724 / embabel#1852). Signed-off-by: arimu1 <19286898+arimu1@users.noreply.github.com>
019aad8 to
f631de1
Compare
|
@igordayen Good question — no, we no longer need the unstructured message fallback. Change (tip after rebase)Removed the
Prose-only messages without JSON Ready for re-review when convenient. |
|
@igordayen Thanks — pushed the early usability path: Early prevention
Cleanup (review threads)
Tests
Tip: |
|
@igordayen Addressed remaining review threads on the retry safety-net:
Early |
There was a problem hiding this comment.
@arimu1 - thanks for the next iteration. this PR is tagged for this week release. Please try to address inquiries. and plan - as research for now - capabilities check, see
#1585 Design: Capability-based model abstraction
also - could you mark items as "resolved" - whatever gets actually resolved.
Regards.
Address @igordayen review on embabel#1852: - Rename UnsupportedRequestParameterHandler → UnsupportedRequestParameterRetry (avoid "Handler" event/callback connotation) - Expand KDoc: not a structured provider API; OpenAI/Azure message pattern; fail closed; strip when() maintenance notes; normalize is not Spring binder - Clarify Prompt.options is required for strip retry (else rethrow) - Rename SpecialHandlingConfiguration → SupportFeaturesConfiguration (YAML special_handling property name unchanged) Tests: UnsupportedRequestParameterRetryTest, InstrumentedChatModelTest, CapabilityAwareOpenAiOptionsConverterTest, OpenAiModelLoaderTest green (JDK 21) Signed-off-by: arimu1 <19286898+arimu1@users.noreply.github.com>
…of truth Rebased on main (OptionsConverter 2-arg + model stamp). Prefer OpenAI JSON error.param from Spring AI exception messages over English wording for the one-shot retry safety net. Declarative omit via openai-models.yml special_handling → ModelCapabilities remains the primary path (LLM model database). Fail closed on non-retryable error codes. Addresses igordayen design feedback on embabel#1852 / embabel#1724. Signed-off-by: arimu1 <19286898+arimu1@users.noreply.github.com>
Prefer fail-closed extraction: only structured OpenAI JSON error.param (with known codes) drives the one-shot strip/retry. Prose-only "Unsupported value: '…'" messages no longer match. Model YAML special_handling remains the primary defence (embabel#1724 / embabel#1852). Signed-off-by: arimu1 <19286898+arimu1@users.noreply.github.com>
2ec39f7 to
5246b91
Compare
|
@igordayen Expedited for this week's release — tip Consistency with #1874
Require-style fail-fast (vs warn-and-ignore)
Review threads addressed
#1585 (capabilities design) — research note, not a rewrite
Tests (JDK 21)
All open review threads on this PR marked resolved for the fixed items. Ready for re-review / merge when convenient. |
This JUNIT to produce an exception is a good one: But I actually was looking for a test that starts with to ensure the exception will be propagated and caught there. From your comments, I'm not sure I fully understand whether you are considering using Kotlin "require" or not, and what the remaining items (if any) are. |
|
@arimu1 - could you please respond, thank you |
|
@igordayen Sorry for the delay — answering your questions: Kotlin
|
Looping @alexheifetz @arimu1 - @azanux presented a very strong argument against the exception (cost-related) and can't beat it:) Let's plan a strategically proper solution using the capabilities API Please go with the originally planned warning and rebase. Appreciate accommodating requests "as we go along the journey" :) |
Rebase onto main after embabel#1874. Keep the capabilities API (ModelCapabilities + per-model special_handling) as the primary path, but warn and drop unsupported sampling parameters instead of throwing — same strategy as Gpt5ChatOptionsConverter on main (igordayen / embabel#1852). - CapabilityAwareOpenAiOptionsConverter consults ModelCapabilities; omits restricted fields and logs a warning (default temperature omitted silently) - GPT-5 catalog flags: no sampling + uses_max_completion_tokens - GPT-4.1 catalog: temperature-only restriction (still max_tokens) - HTTP safety net: structured error.param strip/retry in InstrumentedChatModel - Tool options fallback only copies non-null ChatOptions fields Fixes embabel#1724 Signed-off-by: arimu1 <19286898+arimu1@users.noreply.github.com>
5246b91 to
81f0ba5
Compare
|
@igordayen Done — switched to the originally planned warn-and-drop path and rebased onto latest Behavioral change
Rebase
Tests (JDK 21 Temurin 21.0.12) — green46 tests, 0 failures. Tip
|
Summary
Fixes #1724 — some OpenAI models (GPT-5 family, and now GPT-4.1) return 400 when non-default sampling parameters such as
temperatureare sent.This PR implements both strategies from the issue:
Declarative capabilities (primary)
ModelCapabilities+CapabilityAwareOpenAiOptionsConverterthat omits unsupported parameters (temperature, topP, frequency/presence penalty).Gpt5ChatOptionsConverter/StandardOpenAiOptionsConverterbranch inOpenAiModelsConfigwith a single capability-aware converter.SpecialHandlingConfiguration(YAMLspecial_handling) with optional flags for topP and penalties.supports_temperature: false(GPT-5 family already marked).Gpt5ChatOptionsConverter/StandardOpenAiOptionsConverteras thin aliases for compatibility.Defensive retry (safety net)
InstrumentedChatModelcatches provider errors matchingUnsupported value: '<param>', strips that parameter from chat options, logs a warning, and retries once.Message-sender fix
SpringAiLlmMessageSenderfallback tool-options path only copies non-null parameters so intentionally omitted values (e.g. temperature) are not re-introduced.Test plan
CapabilityAwareOpenAiOptionsConverterTest— default / temperature-restricted / multi-param restricted / aliasesGpt5ChatOptionsConverterTest+StandardOpenAiOptionsConverterTestUnsupportedRequestParameterHandlerTest— parse OpenAI error + strip paramsInstrumentedChatModelTest— retry on unsupported temperature; no retry for unrelated errorsSpringAiLlmMessageSenderTest— fallback does not re-add omitted temperature when attaching toolsOpenAiModelLoaderTest— GPT-4.1 models load withsupports_temperature: falseJDK 21 — all green.
Notes